Skip to content

feat: add ConnectedButtonGroup component - #5074

Open
oleksandrzavarzin-callstack wants to merge 12 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:feat/connected-button-group
Open

feat: add ConnectedButtonGroup component#5074
oleksandrzavarzin-callstack wants to merge 12 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:feat/connected-button-group

Conversation

@oleksandrzavarzin-callstack

Copy link
Copy Markdown

Motivation

Continues #5028. Addresses @alpharius-ck's review comments, and fixes two corner-animation bugs found while verifying those fixes on device: a button that could get stuck in the wrong shape, and a square flash on the middle button of a group.

Related issue

Review comments on #5028.

What changed and why

Review comments:

  • Fixed the deprecation JSDoc link on SegmentedButtons and added a note on
    migrating densitysize.
  • Restored the check/icon swap animation to match SegmentedButtonItem.
  • Extracted position literals into connectedButtonPositions and a
    morphingCorners lookup table.
  • Hoisted getTestID out of the component.
  • Extracted shared types into types.ts.

Animation bugs:

  • Three separate handlers were animating one shared value, so a single tap ran
    three spring transitions instead of one. Fixed by deriving the radius from
    current props in a single useDerivedValue.
  • Re-pressing an already-selected button never re-renders the group, so a
    swallowed onPressOut could strand the button in its pressed shape. Fixed
    by also clearing the pressed state on press.
  • cornerFull is a sentinel value of 9999, not a real radius — animating to
    it caused a large spring overshoot that flashed the corner square. Now
    resolved to containerHeight / 2 instead.

The press-in morph itself isn't covered by a test: the reanimated jest mock
doesn't track shared-value mutations, so it's verified manually instead,
matching how Switch.tsx already handles this.

Test plan

  • yarn typecheck, yarn lint — clean.
  • yarn test ConnectedButtonGroup — 28 tests (up from 23).
  • Manually verified on an Android emulator: press-and-hold morphs the
    connected corner on both selected and unselected buttons; repeated taps on
    the selected button never strand the shape; the middle button no longer
    flashes square; deselecting a labelled button animates the icon back in.
Screen.Recording.2026-08-25.at.12.58.09.mov

matkoson and others added 12 commits July 14, 2026 04:17
…group

Introduces ConnectedButtonGroup, the MD3 successor to SegmentedButtons.
Selected buttons morph to a fully-rounded shape, the connected inner corners
expand on press, and the group supports single- and multi-select across the
extra-small to extra-large size scale.

Component-specific tokens are extracted into tokens.ts; size, shape and color
resolution live in utils.ts. SegmentedButtons is marked as deprecated in
favour of the new component.
…ty overlay

Pre-blending the disabled container color with color().alpha() breaks on
Android dynamic themes where onSurface is a PlatformColor object rather
than a string — the fallback rendered a fully opaque near-black container.
Apply the MD3 12% disabled opacity as a style on an absolute-fill overlay
instead, which works with any ColorValue. Found during on-device Android
verification.
- tokens: pressed inner corner now morphs sharper than rest (M3
  ConnectedButtonGroup*Tokens: Inner=Small, PressedInner=ExtraSmall), and
  large/extra-large rest inner corners corrected to large(16)/largeIncreased(20).
- press morph: selected buttons also morph on press (pressed precedence over
  checked, matching Compose ToggleButton); pressOut returns to the rest radius.
- accessibility: buttons now use the radio (single-select) / checkbox
  (multi-select) role instead of button+aria-checked, valid on web and better
  announced natively.
- reduce-motion: shape morph honours the reduce-motion setting (ReduceMotion),
  matching Switch/FAB.
- docs: register ConnectedButtonGroup in component-docs.config so the
  deprecation link and generated page resolve.
- tests: cover shape tokens and role semantics (22 total).
- resolve corner shapes through the shared ShapeToken/resolveCornerRadius
  from theme/utils/shape instead of a local re-implementation.
- drop the per-button rippleColor prop and runtime ripple color derivation;
  TouchableRipple's stateLayerPressed default applies, consistent with the
  removal of customRippleColor across components.
- expose background and hitSlop passthroughs per button, mirroring
  SegmentedButtons, and remove the ineffective automatic touch-target
  expansion.
- drive the corner morph with the theme's fast spatial spring (toRawSpring),
  matching Switch and FAB, instead of timing curves.
- add radiogroup semantics to the single-select row and collision-proof
  list keys.
- honour intrinsic button widths in content-sized parents (flexBasis auto).
- export ConnectedButtonGroupSize, register a docs screenshot, keep the
  docs config alphabetical, and extend tests with render snapshots (23).
The docs build requires a committed page under 5.x/docs/components for
every entry in component-docs.config; add the ConnectedButtonGroup page
(usage, props, theme colors) mirroring SegmentedButtons, regenerate the
6.x content, and point the SegmentedButtons deprecation notice at the
correct relative path so the link resolves.
@oleksandrzavarzin-callstack oleksandrzavarzin-callstack changed the title Feat/connected button group feat: add ConnectedButtonGroup component Aug 25, 2026
Comment on lines +161 to +166
// A gap is needed before the label, and between the check and the leading
// icon when an icon-only button shows both.
const iconGap =
label || (showCheck && showIcon)
? { marginEnd: sizeStyle.iconLabelGap }
: null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we use separate gaps for check & leading icon?
for a selected icon-only button, both elements render & both receive iconGap (source), leaving an extra trailing margin & shifting the pair off-center

Suggested change
// A gap is needed before the label, and between the check and the leading
// icon when an icon-only button shows both.
const iconGap =
label || (showCheck && showIcon)
? { marginEnd: sizeStyle.iconLabelGap }
: null;
const checkGap = label || (showCheck && showIcon) ? { marginEnd: sizeStyle.iconLabelGap } : null;
const leadingIconGap = label ? { marginEnd: sizeStyle.iconLabelGap } : null;

Comment on lines +121 to +152
export type Props<T extends string = string> = {
/**
* Buttons to display as options in the group. Each button should contain the
* following properties:
* - `value`: value of the button (required)
* - `icon`: icon to display for the button
* - `label`: label text of the button
* - `disabled`: whether the button is disabled
* - `aria-label`: accessibility label for the button
* - `checkedColor`: custom color for the selected label and icon
* - `uncheckedColor`: custom color for the unselected label and icon
* - `showSelectedCheck`: show an optional check icon to indicate the selected state
* - `onPress`: callback that is called when the button is pressed
* - `style`: pass additional styles for the button
* - `labelStyle`: style for the button label
* - `testID`: testID to be used on tests
*/
buttons: ConnectedButtonConfig<T>[];
/**
* Size of the buttons, following the Material Design 3 button-group scale.
*/
size?: ConnectedButtonGroupSize;
style?: StyleProp<ViewStyle>;
/**
* testID to be used on tests.
*/
testID?: string;
/**
* @optional
*/
theme?: ThemeProp;
} & ConditionalValue<T>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdyt about making selection props visible in generated docs? because they come from ConditionalValue<T> intersection, the generated data lists only buttons, size, style, testID, theme (source); required value/onValueChange & multiSelect are missing

it would be great to make docs parser flatten intersection props & regenerate the docs. or at least add generated “Selection props” section documenting these three fields

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about keeping this page in the 6.x docs only? ConnectedButtonGroup isn't exported by v5.15.3, while 5.x prop tables read componentDocs5x.json, which has no entry for it. this advertises unavailable import & renders empty prop details

so could we remove 5.x page/meta entry & exclude ConnectedButtonGroup from getVersionComponentOrder('5.x') ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants